You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
MCP: 2026-07-28 core, legacy MCP as a deletable edge (#1671, #1670) - #1672
Registrations, tool filters (new McpToolFilter) and interceptors use the SDK's modern types and run once per request, in either era.
Breaking (3.0 is at M1, no deprecation step): the 2025-11-25 registration lists are removed; getTools(), getResources(), getResourceTemplates(), getPrompts() take modern types; McpModernResource/McpModernResourceTemplate are renamed McpResource/McpResourceTemplate; dispatch() and setServerInfo() take modern types.
Legacy requests go through the McpLegacyEdge seam to internal.legacy.McpLegacyServer: initialize answered from server/discover (tools, resources, prompts only), ping empty result, notifications dropped, logging/setLevel and resources/subscribe return -32601, no session. Legacy clients get the final result of a streaming tool, and an error instead of input_required.
McpLegacyBoundaryTestCase enforces the three boundary rules by scanning sources. One allow-listed exception: the SDK's modern header decoder takes HeaderAccessor from a legacy package.
The SDK's legacy client and both legacy transports move to internal.legacy. Removed: getSdkClient(), StdioClientHelper.getTransport(), LEGACY_INPUT_KEY, the public RestletMcpClientTransport.
Auto mode probes server/discover and falls back to initialize on a non-modern error; the era is cached per endpoint.
listTools, callTool, readResource, getPrompt return modern types in both eras.
With a legacy server, the client declares no capabilities and answers server requests with -32601.
Opt-in legacy sessions: setLegacySessions(boolean), setLegacySessionId(String) (caller-owned, sent but never deleted), getLegacySessionId().
Not done
Over stdio, auto mode speaks 2025-11-25: there's no modern stdio client yet.
The server edge accepts only 2025-11-25 in initialize, as before.
Registrations, tool filters and interceptors now use the SDK's modern
types. MCP 2025-11-25 traffic is translated at a legacy edge in
internal.legacy, behind the McpLegacyEdge seam, which exposes nothing
2026-07-28 deprecates or removes. An architecture test enforces the
boundary.
The client detects the server's era (auto mode), falls back to legacy
servers without capabilities, and keeps their session only on opt-in,
through setLegacySessions() or setLegacySessionId().
Closes#1671, closes#1670.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Legacy interceptor failures lose request IDs and can fail the stdio transport, while reply-handling, shutdown-timeout, and discovery-fallback defects also remain unresolved.
This PR moves the Restlet MCP extension to a modern SDK core, isolating legacy compatibility behind a removable edge. It also adds opt-in HTTP era detection and legacy session support.
Changes:
Unifies server registrations, filters, interceptors and common client results on modern SDK types.
Isolates legacy transports and translation, with restricted capabilities and optional sessions.
Updates integration tests, adds boundary checks and documents the API changes.
…ssion deletion
Address the first Copilot review:
- A legacy request failing past the edge, in an interceptor or at the
deadline, is answered with its id once decoded, not a null id that a
stdio client can't match to its call.
- server/discover failing with a header mismatch or a missing client
capability error comes from a modern server: auto mode keeps the
failure instead of falling back to initialize.
- A request a legacy server sends on a response stream is refused with
-32601 with or without a session, and the refusal's empty 202 is
accepted.
- Notifications and replies, which open no stream, take workers of
their own: a refusal, or notifications/initialized, no longer waits
for the call slot the exchange it follows still holds, so a client
with one call slot can initialize and refuse.
- The session DELETE on close runs on its own thread and is aborted at
the request timeout, so a stalled server no longer blocks stop().
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
A delayed 404 from an expired session can invalidate its valid replacement and disrupt concurrent calls, alongside unresolved protocol-recovery, version-tracking, and test-coverage gaps.
After a server-issued legacy session expires, the SDK initializes again and the transport updates its negotiated version from the new response. If that handshake selects a different supported revision, this getter still reports the initial version: both this field and McpLegacyClient.negotiated are snapshots of the first handshake. Read the legacy client's current negotiated version, keeping it synchronized with the transport, and cover a version change after session expiry.
Cached startup failures trigger unsafe era rediscovery
Any cached startup failure clears the era and retries discovery, including timeouts and HTTP 5xx responses that do not establish a protocol mismatch. This sends extra requests during outages and can repeat a session-creating handshake after an ambiguous timeout. Preserve these failures, re-probe only when the cached era is contradicted, and extend the server-error test to cover cached eras.
Protocol mismatch recovery is missing after startup
Era recovery runs only during startup: after an auto client starts, all four common operations keep using the selected client even if the endpoint changes eras. The client therefore cannot perform the promised one-time re-probe or correct its cached era when a later operation exposes the mismatch. Add coordinated runtime recovery for protocol mismatches without blindly replaying potentially side-effecting tool calls.
The legacy tests negotiate only 2025-11-25, but this now accepts three earlier revisions in auto mode. That behavior also depends on the SDK accepting the negotiated revision and the transport switching subsequent version headers. Add parameterized legacy handshakes for the earlier advertised revisions, assert a subsequent call uses the negotiated header, and cover rejection of an unlisted revision; the current fixtures do not exercise this compatibility expansion.
Legacy import boundary test misses wildcard imports
This pattern does not match wildcard imports such as import io.modelcontextprotocol.spec.*; (or the corresponding server.* and client.* imports). Consequently, a core class can import legacy SDK types through a wildcard and still pass onlyTheLegacyPackageUsesTheLegacySdk(), defeating the boundary this test is meant to enforce. Include wildcard imports in the matches while retaining the exact HeaderAccessor exception.
Address the second Copilot review:
- A delayed 404 for a legacy session already replaced fails its request
without reporting an expiry to the SDK client, which would otherwise
initialize again and drop the valid replacement.
- getNegotiatedVersion() follows the handshake made after an expired
legacy session, which may negotiate another revision.
- An era remembered for an endpoint is probed again only when the
server contradicts it, with a 4xx or a JSON-RPC error the other era
would answer with. A timeout or a 5xx is thrown as is and keeps the
remembered era, so an outage adds no request and an ambiguous
handshake is not repeated. The legacy transport now reports HTTP
failures with their status for that.
- A started client keeps its era and replays nothing: the Javadoc now
says so, instead of promising a later re-probe.
- Tests negotiate each earlier legacy revision, check the header sent
after it, and refuse one the client doesn't offer.
- The legacy boundary test catches wildcard imports of the legacy SDK.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Answers to the five findings in the body of the second Copilot review (c2350ac):
Negotiated-version getter remains stale after session expiry: fixed in e568ed5. getNegotiatedVersion() now reads the legacy client's current revision, which the transport updates on each initialize. reportsTheRevisionNegotiatedAfterTheSessionExpires expires the session, makes the next handshake negotiate 2025-06-18, and checks both the getter and the header sent after it.
Cached startup failures trigger unsafe era rediscovery: fixed in e568ed5. A cached era is probed again only when the server contradicts it with a 4xx or a JSON-RPC error that the other era would answer with. A timeout or a 5xx is thrown as is and keeps the cached era. The legacy transport now reports HTTP failures with their status, so a 4xx on initialize is recognized. doesNotFallBackOnAServerError now also covers cached modern and legacy eras (one request each, cache kept). probesAgainWhenAModernServerRefusesTheHandshake checks that a 400 on a cached legacy era still leads to the modern client.
Protocol mismatch recovery is missing after startup: declined. A started client keeps its era by design. Switching eras in the middle of a running client would mean replaying requests that may already have run on the server, such as tools/call, and the review rightly warns against that. Recovery happens at the next start instead: with item 2, a start whose cached era the server contradicts forgets that era and probes again. The Javadoc promised a re-probe "if it later proves wrong", which overstated this. In e568ed5 it now says that a started client keeps its era, and that after a server changes era you stop the client and start a new one.
Legacy compatibility tests omit earlier advertised revisions: fixed in e568ed5 (tests only; the code already handled this). negotiatesEachLegacyRevision runs the handshake for each of 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05. It checks the reported revision and the MCP-Protocol-Version header of the call that follows. It also checks that a revision the client doesn't offer (2024-10-07) fails the start and caches no era.
Legacy import boundary test misses wildcard imports: fixed in e568ed5. The pattern now ends with [\w.]*[\w*], so spec.*, server.*, client.* and static wildcard imports match. The HeaderAccessor exception still matches exactly. theLegacySdkPatternCatchesWildcardImports covers both cases.
Each fix comes with a regression test that fails without it. Item 4 is coverage only, so its test passes on the old code too. ./mvnw -o -B verify -P default,test -pl org.restlet.ext.mcp -am passes: 122 MCP tests, 0 failures.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Modern requests can expose a legacy protocol version to interceptors and tool filters, and correlated HTTP 503 errors can incorrectly trigger legacy fallback and discard the cached modern era.
No open issues 🎉
2 previously missed issues
In code that hasn't changed since last review
Preserve HTTP 5xx status to prevent erroneous legacy fallback
An HTTP 503 with a correlated JSON-RPC error such as -32603 loses its HTTP status in McpModernHttpTransport.single() and reaches this branch as an McpException. Auto mode then attempts a legacy handshake and may discard a cached modern era during a transient outage. Preserve the status for classification, avoid fallback on 5xx responses, and extend doesNotFallBackOnAServerError with a correlated JSON error body.
Default request context reports legacy protocol for modern dispatch
The dispatcher now accepts modern messages, but the one-argument McpRequestContext constructor still defaults to 2025-11-25 (McpRequestContext.java:23–24). Passing that context, as the updated module test does, makes interceptors and tool filters see a modern request as legacy through protocolVersion(), which can select the wrong era-dependent policy. Align the default context with modern dispatch, retaining explicit legacy contexts at the edge, or normalize/validate the revision here.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1671 and #1670. Design memo: https://claude.ai/artifact/73QEwzKyg79YHVHjAyHdLd
Server (#1671)
McpToolFilter) and interceptors use the SDK's modern types and run once per request, in either era.getTools(),getResources(),getResourceTemplates(),getPrompts()take modern types;McpModernResource/McpModernResourceTemplateare renamedMcpResource/McpResourceTemplate;dispatch()andsetServerInfo()take modern types.McpLegacyEdgeseam tointernal.legacy.McpLegacyServer:initializeanswered fromserver/discover(tools, resources, prompts only),pingempty result, notifications dropped,logging/setLevelandresources/subscribereturn -32601, no session. Legacy clients get the final result of a streaming tool, and an error instead ofinput_required.McpLegacyBoundaryTestCaseenforces the three boundary rules by scanning sources. One allow-listed exception: the SDK's modern header decoder takesHeaderAccessorfrom a legacy package.Client (#1670)
internal.legacy. Removed:getSdkClient(),StdioClientHelper.getTransport(),LEGACY_INPUT_KEY, the publicRestletMcpClientTransport.server/discoverand falls back toinitializeon a non-modern error; the era is cached per endpoint.listTools,callTool,readResource,getPromptreturn modern types in both eras.setLegacySessions(boolean),setLegacySessionId(String)(caller-owned, sent but never deleted),getLegacySessionId().Not done
initialize, as before.Tests
./mvnw -B verify -P default,test -pl org.restlet.ext.mcp: 112 tests pass, coverage and SpotBugs checks met.🤖 Generated with Claude Code